Skip to content

feat(mcp): rotate MCP OAuth refresh tokens with replay detection - #8273

Open
andypalmi wants to merge 27 commits into
mainfrom
feat/8270-mcp-refresh-token-rotation
Open

feat(mcp): rotate MCP OAuth refresh tokens with replay detection#8273
andypalmi wants to merge 27 commits into
mainfrom
feat/8270-mcp-refresh-token-rotation

Conversation

@andypalmi

@andypalmi andypalmi commented Aug 25, 2026

Copy link
Copy Markdown
Contributor

Summary

Rotates the MCP OAuth refresh token on every refresh, with a grace window and replay detection, replacing the stable (non-rotating) refresh token.

  • Each refresh issues a new refresh token and records the previous one. A rotated-out token presented within a short grace window returns the current tokens, so a concurrent or retried refresh still succeeds.
  • Presented after the window, a rotated-out token is treated as a replay and the grant is revoked so its access tokens stop working and the client must re-authorize (RFC 9700 section 4.14.2).
  • Concurrent refreshes are serialised with a compare-and-swap on the current refresh token: the update only matches while the token is still current, so exactly one refresh rotates and the loser returns the winner's tokens rather than a token that never reached the row.
  • The refresh route defers rotated-token resolution to the controller for MCP clients. It previously rejected any refresh token that was no longer the row's current token, which is the normal case for a rotated MCP token, so the grace window and replay detection never ran through /account/token. A route-level test now drives rotation, grace retry, and replay through the endpoint.

Part of #7423. Closes #8270.

andypalmi added 11 commits August 25, 2026 10:52
Add RFC 8414 authorization-server metadata and RFC 9728 protected-resource
metadata under /.well-known so MCP clients can auto-discover the OAuth
endpoints and the MCP resource URL. Public, license-tier independent.

Ref #7431
…agents

Extend the OAuth2 flow so external MCP agents (Claude, Cursor, etc.) can
authenticate. Rather than a single hardcoded client id, agents register
dynamically per RFC 7591: POST /account/client persists a public AuthClient
(type 'mcp', no secret) with its approved redirect URIs and returns a generated
client id. The authorize, complete, and token endpoints recognise these clients
by looking them up, skip the project/device ownership checks (MCP is
user-scoped), and drive an MCP consent step that records the read-only and team
selection before issuing a scoped personal access token. Redirect URIs must be
loopback http (RFC 8252, port-flexible) or https for hosted clients; token
issuance and refresh require no client secret.

Also forward the caller scope through the platform automation handler and add a
platform_get_active_user tool that reports the calling token's scope.

Adds AuthClient.type/name/redirectURIs (migration + model), an
AuthClient.createMCPClient controller, and AccessToken.createMCPOAuthToken.

Ref #7432
Add the AccessRequestMCP.vue consent page and its /account/request/:id/mcp
modal route. The page lets the user pick read-only or full access and scope
the grant to specific teams before approving the MCP agent request.

Ref #7433
Move the RFC 9728 protected-resource document out of the root
.well-known handler into the license-gated EE mcp plugin, so it is only
advertised where the /mcp resource exists. Serve it at the path-inserted
/.well-known/oauth-protected-resource/mcp (RFC 9728 3.1) with the bare
path kept as an alias, and challenge unauthenticated /mcp requests with a
WWW-Authenticate header pointing at that metadata.
An AccessToken row holds both the access token and its refresh token,
and getOrExpire destroyed the row when the access token expired, taking
the refresh token with it so a client could never refresh (RFC 6749
1.5). Add a separate refreshTokenExpiresAt lifetime: reject an expired
access token but keep the row while its refresh token is still valid.

The MCP refresh token is stable rather than rotating, and concurrent
refreshes coalesce through a shared cache so they reuse the most
recently minted access token instead of overwriting the row.
Capture the pre-refresh expiry after lowering it, so the assertion
compares against the shortened lifetime rather than the original one and
no longer ties when the refresh lands in the same millisecond.
Link the MCP OAuth token to the AuthClient it was issued to and name it
after the registered client instead of the fixed 'MCP Agent', so the
user's token list and later audit attribution can tell agents apart.
Rotate the refresh token on every MCP refresh instead of keeping it
stable: each refresh issues a new refresh token and records the previous
one. A rotated-out token presented within a short grace window returns
the current tokens, so a concurrent or retried refresh still succeeds;
presented after the window it is treated as a replay and the grant is
revoked (RFC 9700 section 4.14.2).

Concurrent refreshes are serialised with a compare-and-swap on the
current refresh token: the update only matches while the token is still
current, so exactly one refresh rotates and the loser returns the
winner's tokens from the shared cache rather than a token that never
reached the row.
@andypalmi
andypalmi requested a review from cstns August 25, 2026 10:41
@andypalmi andypalmi self-assigned this Aug 25, 2026
@codecov

codecov Bot commented Aug 25, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 82.22222% with 8 lines in your changes missing coverage. Please review.
⚠️ Please upload report for BASE (feat/8271-mcp-audit-attribution@e09546a). Learn more about missing BASE report.

Files with missing lines Patch % Lines
forge/db/controllers/AccessToken.js 90.62% 3 Missing ⚠️
...20260825-03-add-refresh-token-rotation-tracking.js 62.50% 3 Missing ⚠️
forge/routes/auth/oauth.js 60.00% 2 Missing ⚠️
Additional details and impacted files
@@                        Coverage Diff                         @@
##             feat/8271-mcp-audit-attribution    #8273   +/-   ##
==================================================================
  Coverage                                   ?   76.25%           
==================================================================
  Files                                      ?      451           
  Lines                                      ?    24175           
  Branches                                   ?     6454           
==================================================================
  Hits                                       ?    18435           
  Misses                                     ?     5740           
  Partials                                   ?        0           
Flag Coverage Δ
backend 76.25% <82.22%> (?)

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

andypalmi added 3 commits August 25, 2026 13:14
The refresh route rejected any refresh token byRefreshToken could not find.
For a rotated MCP token that is the normal case: its hash has moved to
previousRefreshToken, so the guard short-circuited the grace window and
replay detection in refreshToken() before either could run.

Defer resolution to refreshToken() for MCP clients, which handles the
current-or-previous token, the grace window, and replay revocation. Add a
route-level test covering rotation, grace retry, and replay through
/account/token.
Platform tool calls were always stamped source 'mcp:expert', so a
third-party agent's actions rendered as the first-party Expert. The MCP
door now records the caller's registered client name against its
mcpSessionId, and the comms handler reads it to stamp source 'mcp' with
that client name. The Expert path never opens that door, so it keeps the
'mcp:expert' default. The audit entry surfaces the client name.

Closes #8271.
andypalmi added 10 commits August 25, 2026 15:18
getOrExpire eager-loaded AuthClient on every token lookup so the client
name could be attached to the session, adding a join to every Bearer
request when only MCP tokens set AuthClientId. Drop the join and resolve
the name with a targeted lookup that runs only when AuthClientId is set.
The refresh_token grant returned access_token, expires_in and
refresh_token but omitted token_type, which RFC 6749 section 5.1
requires. A spec-compliant client rejects the response and falls back to
re-authorization even though the server rotated the token successfully.
Return token_type: bearer to match the authorization_code response.
@cstns

cstns commented Aug 25, 2026

Copy link
Copy Markdown
Contributor

it's unclear to me what we're fixing here

@cstns

cstns commented Aug 25, 2026

Copy link
Copy Markdown
Contributor

It looks to me that we're treating a symptom before it presented it's effects; could this be postponed to 3.1?

andypalmi added 2 commits August 25, 2026 19:14
Run third-party MCP platform actions under the caller's own PAT rather
than a freshly minted platform token, and derive the audit source from
whether the session has one. The door stashes the PAT against the
mcpSessionId; the comms layer injects it and marks the entry 'mcp', or
mints a token and marks 'mcp:expert' for the first-party Expert path.

Removes the per-client identity plumbing (AuthClientId column, client
name lookup and rendering), which is not needed to distinguish
expert, third-party and api sources.
…resh-token-rotation

# Conflicts:
#	forge/db/models/AccessToken.js
@andypalmi

Copy link
Copy Markdown
Contributor Author

Yes, let's postpone this. Agreed it is security hardening rather than something the OAuth flow needs to function, so I'll pull it out of the stack into its own PR and target it separately (3.1 works for me).

For context on what it does: the base refresh flow (exchanging a valid refresh token for a new access token) already lands in #8264 and is complete on its own. This PR adds hardening on top: rotating the refresh token on each use, a compare-and-swap so two concurrent refreshes converge on one result, a short grace window for retries, and replay detection that revokes the grant if an already-rotated refresh token is presented again (RFC 9700 section 4.14.2). None of that is required for refresh to work, which is why it can land later without blocking the rest of the stack.

An error occurred while trying to automatically change base from feat/8271-mcp-audit-attribution to feat/7433-mcp-consent-page August 26, 2026 09:13
…-token-rotation

# Conflicts:
#	forge/db/controllers/AccessToken.js
#	forge/db/controllers/AuthClient.js
#	forge/db/migrations/20260824-01-add-mcp-authclient-fields.js
#	forge/db/models/AccessToken.js
#	forge/db/models/AuthClient.js
#	forge/routes/auth/oauth.js
#	test/unit/forge/db/controllers/AccessToken_spec.js
#	test/unit/forge/routes/auth/oauth_spec.js
@andypalmi
andypalmi changed the base branch from feat/8271-mcp-audit-attribution to main August 26, 2026 10:22
@andypalmi andypalmi added this to the 3.1 milestone Aug 27, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: No status

Development

Successfully merging this pull request may close these issues.

Rotate MCP OAuth refresh tokens with replay detection

2 participants